使用 koa2 搭建服务器

关于前后端路由区别:浅谈前后端路由与前后端渲染

目前,我在搭建一个包含前后端全套打包方案的工程时,碰到了一些问题,也学到了很多,在此记录下来,防止遗忘。

项目地址

准备

初始化工程

npm init -y

我这里使用 koa 来搭建服务器,之后会尝试用原生 nodejs 去实现一遍。

安装 koa

npm install koa --save

搭建基本服务器

新建我们的服务器文件并写入以下内容:

server.js

const Koa = require('koa');
const app = new Koa();

const port = 3000;

app.use(async (ctx, next) => {
    ctx.body = 'hello world!';
});

app.listen(3000);
console.log(`\nserver is start at port ${port}...\n`);
  • app.use() 方法可以传入一个回调函数,所有请求都会经过 app.use 回调函数处理。这个回调函数也叫中间件,可以直接写在 app.use() 里,也可以拆分成一个独立函数传入 app.use()。
  • ctx 是一个上下文对象,包含一次请求的所有信息,包括 response 和 request 对象。
  • app.listen() 监听一个端口。

现在我们打开浏览器输入地址 ‘localhost:3000’,就可以看到我们的 ‘hello world’ 了。

静态资源访问

在单页应用中,我们希望前端去处理路由请求,即根据不同路由去渲染不同页面。当浏览器访问一个路径时,如果是前端路由,此时后端返回给前端的应该是 index.html,我们需要服务器支持这种请求。

安装 koa-static

npm install koa-static --save

server.js

const Koa = require('koa');
const path = require('path');
const serve = require('koa-static');
const app = new Koa();

const port = 3000;

const main = serve(path.join(__dirname), "dist");   // __dirname 指根目录
app.use(main);

app.listen(3000);
console.log(`\nserver is start at port ${port}...\n`);

注意:
这里有一个比较坑的地方,__dirname 是 nodejs 的一个变量,指的是根目录。我一直以为这个根目录指的是整个工程的根目录。
错!
这个根目录指的是被执行的文件所在的目录!
不一定是整个工程的的根目录!

我当前的目录结构为

nodejs-server-demo
├── backend
│   ├── server.js
│   dist
│   ├── index.html
│   └── bundle.js
└── package.json

根据我的目录结构,我的 server.js 中的 __dirname 指的就是 “/nodejs-server-demo/backend”,而不是 “/nodejs-server-demo”。

我的 index.html 文件放在 ./dist 文件夹下,所以这里我要修改路径为:

const serve = serve(path.join(__dirname, "../dist"));

现在,我们的服务器支持静态资源访问了,打开浏览器,输入 “localhost:3000” 和 “localhost:3000/bundle.js” 查看效果吧。

注意:
我们给 koa-static 中间件传入了一个目录,koa-static 中间件默认返回该目录下的 “index.html” 文件并返回。
如果想验证,我们可以将 ‘app.use(main)’ 注释掉,来看看效果,我们会发现 “localhost” 将返回 ‘not found’。
证明我们的想法是正确的。

路由

要实现路由 ,我们需要借助 “koa-router”。

安装

npm install koa-router --save

server.js

const Koa = require('koa');
const path = require('path');
const serve = require('koa-static');
const router = require('koa-router')(); // 注意 router 的引入方式
const app = new Koa();

const port = 3000;

app.use(serve(path.join(__dirname, "../dist")));

router.get('/about', async (ctx, next) => {
    ctx.type = 'json';
    ctx.body = {
        status: 0,
        data: {
            name: "jaakko",
            age: 21
        }
    };
    await next();
})  // 处理 get 请求 '/about',返回 json 数据

app.use(router.routes());   // 注意 router 使用方式
app.listen(3000);
console.log(`\nserver is start at port ${port}...\n`);

我们现在引入了路由处理,新增了一个 ‘/about’ 的 get 请求路由。

打开浏览器,输入 ‘localhost:3000/about’,我们可以看到我们刚才设置的 json 数据。

区分前后端路由

这部分内容,参考 浅谈前后端路由与前后端渲染,这篇文章写的很详细。

路由歧义

想象这样一个场景:我们某个前端的页面的路由为 ‘/about’,我们还有一个 get 请求路由也为 ‘about’。那么当我们在浏览器地址栏输入 ‘localhost:3000/about’ 时,应该当成前端路由处理还是 get 请求处理?

为了避免这种歧义,我们应该将前后端路由区分开。

前端路由与前端渲染

此时,我们现在在浏览器输入 ‘localhost:3000/about’,会显示 not found。为什么呢?

浏览器访问 ‘localhost:3000/info’ 会发送一个 get 请求,我们的服务器现在没有 ‘/info’ 这个路由,所以返回 not found。

我们通过各种前端框架如 vue、react 的 router 库,去访问页面的路由(假定我们在 router 注册了这个页面,如 ‘localhost:3000/info’)时,为什么不会发送 get 请求呢。是因为它们的实现是通过 h5 history 实现的,可以往地址栏输入一个路径但是不真正地去访问它。这种情况是我们通过点击页面中某些按钮来跳转路由。

如果我们直接在地址栏输入 ‘localhost:3000/info’,还是会返回 ‘not found’,原因正如上文提到,我们通过地址栏去访问这个地址,实际上是发送了一个 get 请求,而服务器 并没有对这个路由进行处理,故返回 ‘not found’。通过 h5 history 去改变路由,可以允许我们改变地址但不发送请求。

服务端解决

我们要解决以上问题要保证两点:

  • 前后端不要使用相同路由
  • 后端在路由无响应时,返回给前端静态页面 ‘index.html’

1. 前后端不要使用相同路由
解决这个问题,我们的做法是:在向服务器请求数据的接口前统一加上 ‘/api’。
那我们刚才的 get 请求完整路径就应该为 ‘localhost:3000/api/about’

2. 后端路由无响应时返回 ‘index.html’

server.js

const Koa = require('koa');
const path = require('path');
const serve = require('koa-static');    // 静态资源操作
const router = require('koa-router')(); // 注意 router 的引入方式
const fs = require('fs');   // 文件操作
const app = new Koa();

const port = 3000;
const main = serve(path.join(__dirname, "../dist"));

app.use(main);

app.use(async (ctx, next) => {
    // 如果路由以 '/api' 开头,进入路由匹配; 否则返回 'index.html'
    if ((/^\/api/.test(ctx.url))) {
        return next();
    }
    ctx.type = "html";
    ctx.body = fs.createReadStream(path.join(__dirname, "../dist", "index.html"));

    await next();
})

router.get('/api/about', async (ctx, next) => {
    ctx.type = 'json';
    ctx.body = {
        status: 0,
        data: {
            name: "jaakko",
            age: 21
        }
    };
    await next();
})

app.use(router.routes());   // 注意 router 使用方式
app.listen(3000);
console.log(`\nserver is start at port ${port}...\n`);

至此,一个简单的 nodejs-koa 服务器就完成了,功能方面还有一些缺陷,我们现在来优化一下。

优化

koa-static

koa-static 方法传入第二个参数可以设置参数,具体选项参考 “这里”。我在这里介绍两个我认为较有用的选项。

1. index

index 选项设置默认返回的文件名,该选项默认值为 “index.html”。上文中我有提到 koa-static 默认返回 “index.html”,就是由于这个设置项的默认值。假如我们希望返回文件名不为 “index.html” 的默认文件,可以手动设置该选项的值。

2. defer
defer 选项设置是否延迟执行 koa-static 中间件。如果该选项为 true,服务器将不返回默认文件,而是先执行之后的中间件。

在我们的服务器中,如果该选项设置为 true,此时再访问就会出错。因为请求会跳过 koa-static 中间件,优先执行 koa-static 之后的中间件。

在 koa-static 之后的第一个中间件,就是下面这个中间件:

app.use(async (ctx, next) => {
    // 如果路由以 '/api' 开头,进入路由匹配; 否则返回 'index.html'
    if ((/^\/api/.test(ctx.url))) {
        return next();
    }
    ctx.type = "html";
    ctx.body = fs.createReadStream(path.join(__dirname, "../dist", "index.html"));

    await next();
})

由上面这段代码我们可以看到,当路由不匹配后端路由(由 ‘/api’ 开头的路由)时,它会对所有的请求都返回 “index.html” 文件,这样肯定有问题。

正确的做法是:判断一下浏览器请求的文件类型,并返回相应文件,而不是统一返回 “index.html”。

server.js

const Koa = require('koa');
const path = require('path');
const serve = require('koa-static');    // 静态资源操作
const router = require('koa-router')(); // 注意 router 的引入方式
const fs = require('fs');   // 文件操作
const app = new Koa();

const port = 3000;
app.use(require('koa-static')('dist', { index: 'index.html', defer: true }));

app.use(async (ctx, next) => {
    await next();
})

app.use(async (ctx, next) => {
    // 如果路由以 '/api' 开头,进入路由匹配; 否则返回 'index.html'
    if ((/^\/api/.test(ctx.url))) {
        return next();
    }
    // 通过 ctx.url 我们可以获取浏览器请求的文件
    const fileName = (/\.html|\.js$/.test(ctx.url));
    // 如果浏览器请求的文件为 html、js 类型,就返回相应类型,否则返回 index.html
    if (fileName) {
        // 将路由前的 '/' 去掉,并返回 dist 目录下相应文件
        ctx.body = fs.createReadStream(path.join(__dirname, "../dist", ctx.url.replace(/^\//, '')));
    } else {
        ctx.type = "html";
        ctx.body = fs.createReadStream(path.join(__dirname, "../dist", "index.html"));

        return next();
    }

})

router.get('/api/about', async (ctx, next) => {
    console.log('get in /about router');

    ctx.type = 'json';
    ctx.body = {
        status: 0,
        data: {
            name: "jaakko",
            age: 21
        }
    };
    await next();
})

app.use(router.routes());   // 注意 router 使用方式
app.listen(3000);
console.log(`\nserver is start at port ${port}...\n`);

现在我们的服务器已经可以满足基本使用需求了,但是我们还可以进一步优化。

我们现在实现了根据不同类型文件请求返回相应的文件,但是当我们的服务器支持多种文件资源请求(如还有 jpg, png, gif 等)时,我们需要手动判断是否为类型,是在有些不便,那么,还有没有更优雅的方式来实现呢?当然,有空再说…

总结

通过搭建这个 nodejs 服务器,我对前后端交互过程有了更进一步了解,也终于弄清楚了前后端路由的区别。
学习是一个从渐悟到顿悟的过程,这个过程可能是痛苦的,但是一旦我们到达了顿悟的点,这一切的痛苦都是值得的。